package org.upfrost;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

import org.upfrost.cache.Cache;
import org.upfrost.converter.Converter;
import org.upfrost.mapping.ClassProxy;
import org.upfrost.mapping.MetaMapManager;

/**
 * Manage the execution of an SQL query. Instances of the Query class should be
 * created by the active session's createQuery() method.
 * <p/>
 * A Query instance should be used to execute one single sql statement. Usage is
 * pretty straightforward:
 * <ul>
 * <li>For sql select queries, use either single() to get a single-line result
 * mapped as a java object, or list() to return a list of rows mapped as a
 * java.util.List of object.</li>
 * <li>For any statement modifying the data, use the update() method</li>
 * <li>You can set the mapped java class with the asClass(), or asArray() if the
 * result returns several java object per row (sql joins)</li>
 * <li>If you query need parameters, use param() to set them up.
 * <li>Alternatively, you can browse the result using the next() method.</li>
 * </ul>
 * 
 * The session keeps track of open Query instances and closes them automatically
 * when it closes itself, so you only need to close a Query instance if you
 * called the constructor directly.
 * 
 * <p/>
 * Example:
 * 
 * <pre>
 * 	String login = session.createQuery("select login from accounts where id=?").asClass(String.class).param(12).single();
 * 	List&lt;MyClass&gt; = session.createQuery("select * from mytable where name=? and lastupdate&lt;?").asClass(TestClass2.class).param("rodrigo", new Date()).single();
 *  Query<Test> query = session.createQuery("select * from test");
 *  Test test = null;
 *  while ( (test=query.next()) != null) {
 *  	doSomethingWith(test);
 *  }
 * </pre>
 * 
 * @author Rodrigo Reyes
 * 
 * @param <T>
 */
public class Query<T> {
	private Mapper m_mapper;
	private Connection m_connection;
	private String m_query;
	private ICacheManager m_cache;

	private ResultSet m_resultSet = null;
	private ArrayList<Object> m_params = new ArrayList<Object>(0);
	private ResultSetMetaData m_meta = null;
	private LinkedList<Join> m_joins = null;
	private MetaMapManager m_mapManager = null;
	private PreparedStatement m_stmt = null;

	private Class<T> m_defaultTargetClass = null;

	private static class Join {
	}

	private static class ClassJoin<C> extends Join {
		public Class<C> clazz;

		public String toString() {
			return clazz.getCanonicalName();
		}
	}

	private static class ObjectJoin extends Join {
		public String columnName;

		public String toString() {
			return "col:" + columnName;
		}
	}

	/**
	 * Create a new Query instance. Do not call this contructor directly, use
	 * MapperSession.createQuery() instead.
	 * 
	 * @param mapper
	 *            a Mapper instance
	 * @param conn
	 *            a database connection
	 * @param query
	 *            an sql query
	 * @param cache
	 *            a cache manager, or null to disable the cache
	 */
	public Query(Mapper mapper, Connection conn, String query,
			ICacheManager cache) {
		m_mapper = mapper;
		m_connection = conn;
		m_query = query;
		m_cache = cache;
	}

	/**
	 * Close the connection and the result set opened by this query. You don't
	 * need to call this method if you used the MapperSession.createQuery().
	 */
	public void close() {
		try {
			if (m_resultSet != null)
				m_resultSet.close();
		} catch (SQLException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		try {
			if (m_stmt != null)
				m_stmt.close();
		} catch (SQLException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		try {
			if (m_connection != null)
				m_connection.close();
		} catch (SQLException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		m_mapper.getStatistics().incClosedQuery();
	}

	public void clear() {
		try {
			if (m_resultSet != null)
				m_resultSet.close();
		} catch (SQLException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		try {
			if (m_stmt != null)
				m_stmt.close();
		} catch (SQLException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

	}

	/**
	 * Set up the parameters for the query. The parameters can be any type,
	 * including mapped java objects, which are then referred to using their
	 * identity (in such a case, the mapped object used as parameters must have
	 * one unique @Id key), and the usual suspects (primary types, String, Date,
	 * and the like).
	 * 
	 * @param params
	 *            array of Object parameters for the query
	 * @return the Query itself, for chainability
	 */
	public Query<T> param(Object... params) {
		if (m_params == null)
			m_params = new ArrayList<Object>(3);

		for (Object o : params) {
			ClassProxy proxy = null;
			if (o != null)
				proxy = m_mapper.getClassProxy(o.getClass());

			if (proxy != null) {
				if (proxy.hasOneKey()) {
					m_params.add(proxy.getIds()[0].get(o));
				} else {
					throw new RuntimeException(
							"Parameter "
									+ o.getClass().getCanonicalName()
									+ " is a proxy type with no key, or has more than one key property");
				}
			} else
				m_params.add(o);
		}
		return this;
	}

	/**
	 * Execute the Query using an update command.
	 * 
	 * @return the number of rows affected.
	 */
	public int update() {
		try {
			if (m_cache != null)
				m_cache.clearCache();

			m_stmt = m_connection.prepareStatement(m_query,
					Statement.RETURN_GENERATED_KEYS);
			setupParams(m_stmt);

			long start = System.currentTimeMillis();
			int res = m_stmt.executeUpdate();
			long end = System.currentTimeMillis();
			double timed = (double) end - (double) start;

			return res;
		} catch (Exception exc) {
			try {
				if (m_stmt != null)
					m_stmt.close();
			} catch (Exception e) {
				e.printStackTrace();
			}

			throw new RuntimeException("Update query failed on query "
					+ m_query, exc);
		}
	}

	/**
	 * Execute the query
	 * 
	 * @return
	 */
	private Query<T> execute() {
		try {
			m_stmt = m_connection.prepareStatement(m_query);

			setupParams(m_stmt);

			long start = System.currentTimeMillis();
			m_resultSet = m_stmt.executeQuery();
			long end = System.currentTimeMillis();

			double timed = (double) end - (double) start;
			m_meta = m_resultSet.getMetaData();

		} catch (Exception exc) {
			if (m_resultSet != null) {
				try {
					m_resultSet.close();
				} catch (SQLException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
				m_resultSet = null;
			}

			try {
				if (m_stmt != null)
					m_stmt.close();
			} catch (SQLException e) {
				e.printStackTrace();
			}

			throw new RuntimeException("Can't execute query " + m_query, exc);
		} finally {
		}

		return this;
	}

	private String normalizeQuery() {
		if (m_joins == null) {
			return m_query;
		} else {
			return m_query + m_joins.toString();
		}
	}

	private Object readCache() {
		if (m_cache != null) {
			Object result = m_cache.readCache(normalizeQuery(),
					m_params.toArray());

			if (result == Cache.NOT_FOUND)
				m_mapper.getStatistics().incDataCacheMiss();
			else
				m_mapper.getStatistics().incDataCacheHit();

			return result;
		}

		return Cache.NOT_FOUND;
	}

	private void writeCache(Object result) {
		if (m_cache != null) {
			m_cache.writeCache(result, normalizeQuery(), m_params.toArray());
		}
	}

	/**
	 * Execute the query and return a single mapped object.
	 * 
	 * @return an instance of T, or null if no row was returned by the query.
	 */
	public T single() {
		Object cached = readCache();
		if (cached != Cache.NOT_FOUND)
			return (T) cached;

		execute();

		if (m_resultSet == null)
			throw new RuntimeException("No resultset for " + m_query);

		try {
			if (m_resultSet.next()) {
				// ClassProxy proxy = m_mapper.getClassProxy(clazz);
				Object o = buildRow(m_resultSet);

				writeCache(o);
				return (T) o;
			}
		} catch (SQLException exc) {
			throw new RuntimeException("Error fetching a row", exc);
		}

		writeCache(null);
		return null;
	}

	private void setupParams(PreparedStatement stmt) {
		if (m_params != null) {
			int index = 1;
			for (Object o : m_params) {
				try {
					stmt.setObject(index++, o);
				} catch (SQLException exc) {
					throw new RuntimeException("Error setting parameter " + o,
							exc);
				}

			}
		}
	}

	/**
	 * Advance the resultset cursor and return the current mapped object.
	 * 
	 * @return the next mapped object, or null if the end of the result set is
	 *         reached.
	 */
	public T next() {
		if (m_resultSet == null) {
			execute();
		}

		try {
			if (m_resultSet.next()) {
				return buildRow(m_resultSet);
			}
		} catch (SQLException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}

		return null;
	}

	/**
	 * Execute the query and return a list of mapped object.
	 * 
	 * @return a list of mapped objects. If no row is returned by the database,
	 *         the list is empty.
	 */
	public List<T> list() {
		Object cached = readCache();
		if (cached != Cache.NOT_FOUND)
			return (List<T>) cached;

		execute();

		if (m_resultSet == null)
			throw new RuntimeException("No resultset for " + m_query);

		LinkedList<T> result = new LinkedList<T>();
		try {
			while (m_resultSet.next()) {
				Object o = buildRow(m_resultSet);
				result.add((T) o);
			}
		} catch (SQLException exc) {
			throw new RuntimeException("Error fetching rows", exc);
		}

		writeCache(result);
		return result;
	}

	private T buildRow(ResultSet rs) throws SQLException {
		if (m_joins == null || m_joins.size() == 0) {
			ClassProxy<?> proxy = findClass();
			if (proxy != null)
				return (T) proxy.get(m_mapper, m_resultSet, null);
			else
				return (T) rs.getObject(1);
		}

		if (m_mapManager == null) {
			m_mapManager = new MetaMapManager(m_mapper, m_meta);
		}

		Object[] result = null;
		if (m_joins.size() > 1)
			result = new Object[m_joins.size()];

		boolean processedAMappedClassAlready = false;
		int index = 0;
		for (Join j : m_joins) {
			Object o = null;
			if (j instanceof ClassJoin) {
				ClassProxy proxy = m_mapper
						.getClassProxy(((ClassJoin) j).clazz);
				if (proxy != null) {
					o = proxy.get(m_mapper, m_resultSet, m_mapManager);
					processedAMappedClassAlready = true;
				} else {
					if (processedAMappedClassAlready)
						throw new RuntimeException(
								"SQL joins that mix mapped and non-mapped classes must have their mapped classes in last position");
					// try a standard conversion with first element
					o = convert(rs.getObject(1 + index), ((ClassJoin) j).clazz);
				}
			} else if (j instanceof ObjectJoin) {
				o = rs.getObject(((ObjectJoin) j).columnName);
			}

			if (result != null)
				result[index++] = o;
			else
				return (T) o;
		}

		return (T) result;
	}

	private void addJoin(Join j) {
		if (m_joins == null)
			m_joins = new LinkedList<Join>();
		m_joins.add(j);
	}

	/**
	 * Map the result of the query to a mapped class. The class must be known by
	 * the mapper or be any type that can be converted by the mapper's
	 * converters (by default, primary types and String are automatically
	 * converted).
	 * 
	 * @param cc
	 *            the class to map the query result to.
	 * @return
	 */
	@SuppressWarnings("unchecked")
	public <X> Query<X> asClass(Class<X> cc) {
		m_joins = null;
		join(cc);
		return (Query<X>) this;
	}

	/**
	 * Map the result of the query to multiple classes. This is typically used
	 * for join, where you need to retrieve several objects per line, but can
	 * also be used to retrieve specific columns for a single table.
	 * <p/>
	 * For instance, to retrieve two specific columns from a row:
	 * 
	 * <pre>
	 * Object[] els = session
	 * 		.createQuery(&quot;select datacount, name from test where id=?&quot;)
	 * 		.asArray(Integer.class, String.class).param(1).single();
	 * </pre>
	 * 
	 * To retrieve two mapped object from a join query (Test1.class and
	 * Test2.class must be added to the mapper with <tt>addAnnotated()</tt>)
	 * 
	 * <pre>
	 * List<Object[]> list = session.createQuery("select * from table1 t1,table2 t2 where t1.id=t2.id").asArray(Test1.class, Test2.class).list();</li></ul>
	 * </pre>
	 * 
	 * @param cc
	 *            a list of classes to map the result to. The mapping is done
	 *            using the originating table of the data and the order of the
	 *            sequence.
	 * @return the Query itself, for chainability
	 */
	public Query<Object[]> asArray(Class... cc) {
		m_joins = null;
		for (Class c : cc)
			join(c);
		return (Query<Object[]>) this;
	}

	private Query<?> asColumnNameType(String columnName) {
		m_joins = null;
		return join(columnName);
	}

	public Query<?> join(Class c) {
		ClassJoin j = new ClassJoin();
		j.clazz = c;
		addJoin(j);
		return this;
	}

	public Query<Object[]> join(String columnName) {
		ObjectJoin j = new ObjectJoin();
		j.columnName = columnName;
		addJoin(j);
		return (Query<Object[]>) this;
	}

	private ClassProxy<?> findClass() throws SQLException {
		ClassProxy<?> found = null;
		// First, check any default target class, and check if it's suitable
		if (m_defaultTargetClass != null)
			found = m_mapper.getClassProxy(m_defaultTargetClass);

		if (found == null) {
			for (int i = 0; i < m_meta.getColumnCount(); i++) {
				int col = i + 1;
				String table = m_meta.getTableName(col);

				found = m_mapper.getClassProxy(table);

				if (found != null)
					return found;
			}
		}

		return found;
	}

	/**
	 * Explicitly return the generated key created by the execution of the
	 * query.
	 * 
	 * @return
	 */
	public Object getGeneratedKey() {
		if (m_stmt == null)
			throw new RuntimeException("No statement");

		try {
			ResultSet rs = m_stmt.getGeneratedKeys();
			rs.next();
			Object key = rs.getObject(1);
			return key;

		} catch (Exception exc) {
			throw new RuntimeException("No generated key", exc);
		}
	}

	public Class getDefaultTargetClass() {
		return m_defaultTargetClass;
	}

	/**
	 * Sets the default mapped target class for this query. This can be
	 * superseded by the asClass() an asArray() methods.
	 * 
	 * @param defaultTargetClass
	 * @return
	 */
	public Query<T> setDefaultTargetClass(Class defaultTargetClass) {
		m_defaultTargetClass = defaultTargetClass;
		return this;
	}

	private Object convert(Object o, Class clazz) {
		if (o == null)
			return null;

		Class org = o.getClass();

		if (clazz.isInstance(o))
			return o;

		for (Converter conv : this.m_mapper.getConverters()) {
			if (conv.handles(org, clazz))
				return conv.convert(o, clazz);
		}

		throw new RuntimeException("Can't convert object " + o + " ("
				+ org.toString() + ") into " + clazz.toString());
	}

}
